# ！/usr/bin/env python
# @Time:2021/4/6 19:21
# @Author:华阳
# @File:Basical BPR.py
# @Software:PyCharm
import random
from collections import defaultdict
from time import time

import numpy as np
from sklearn.metrics import roc_auc_score
import scores
'''
函数说明:BPR类（包含所需的各种参数）
Parameters:
    无
Returns:
    无
'''
class BPR:
    #用户数
    user_count = 943
    #项目数
    item_count = 1682
    #k个主题,k数
    latent_factors = 20
    #步长α
    lr = 0.01
    #参数λ
    reg = 0.01
    #训练次数
    train_count = 100
    #训练集
    train_data_path = 'train.txt'
    #测试集
    test_data_path = 'test.txt'
    #U-I的大小
    size_u_i = user_count * item_count
    # 随机设定的U，V矩阵(即公式中的Wuk和Hik)矩阵
    # 超参数latent_factors 用于设置分解后的矩阵维度 值越大越容易过拟合 值越小容易欠拟合
    U = np.random.rand(user_count, latent_factors) * 0.01 #大小无所谓
    V = np.random.rand(item_count, latent_factors) * 0.01
    #偏置向量
    biasV = np.random.rand(item_count) * 0.01
    #生成一个用户数*项目数大小的全0矩阵
    test_data = np.zeros((user_count, item_count))
    print("test_data_type",type(test_data))
    #生成一个一维的全0矩阵
    test = np.zeros(size_u_i)
    #再生成一个一维的全0矩阵
    predict_ = np.zeros(size_u_i)

    #获取U-I数据对应
    '''
    函数说明：通过文件路径，获取U-I数据
    Paramaters:
        输入要读入的文件路径path
    Returns:
        输出一个字典user_ratings，包含用户-项目的键值对
    '''
    def load_data(self, path):
        #defaultdict它比普通的字典多了一个功能：当查询一个不存在的键时，它会自动为这个键提供一个默认值
        #defaultdict(set)我们首次查询一个不存在的键时，它将返回一个空的集合。
        user_ratings = defaultdict(set)
        with open(path, 'r') as f:
            #按行读取
            for line in f.readlines():
                #按空格划分
                u, i = line.split(" ")
                u = int(u)
                i = int(i)
                #当字典中没有键为U时 会自动创建一个集合set 把i存入集合
                user_ratings[u].add(i)
                #返回一个字典{user:set(item)}
        return user_ratings
    '''
    函数说明：通过文件路径，获取测试集数据
    Paramaters：
        测试集文件路径path
    Returns:
        输出一个numpy.ndarray文件（n维数组）test_data,其中把含有反馈信息的数据置为1
    '''
    #获取测试集的评分矩阵
    def load_test_data(self, path):
        file = open(path, 'r')
        for line in file:
            line = line.split(' ')
            user = int(line[0])
            item = int(line[1])
            #让user和item从0开始 把有交互的地方设为1
            self.test_data[user - 1][item - 1] = 1
    '''
    函数说明：对训练集数据字典处理，通过随机选取，（用户，交互，为交互）三元组，更新分解后的两个矩阵
    Parameters：
        输入要处理的训练集用户项目字典
    Returns：
        对分解后的两个矩阵以及偏置矩阵分别更新
    '''
    def train(self, user_ratings_train):
        for user in range(self.user_count):
            # 随机获取一个用户
            u = random.randint(1, self.user_count) #找到一个user
            # 训练集和测试集的用于不是全都一样的,比如train有948,而test最大为943
            if u not in user_ratings_train.keys():
                continue
            # 从用户的U-I中随机选取1个Item user_ratings_train即用户和商品交互字典{1:{2,5,1,2}....}数据
            """
            random.sample用于从一个序列（例如列表、元组或字符串）中随机选取指定数量的不重复的元素。
            user_ratings_train[u]user_ratings_train是一个字典，其中每个键是用户ID，而对应的值是一个列表或集合
            random.sample(user_ratings_train[u], 1): 这表示从user_ratings_train[u]中随机选取一个元素。1表示我们只想要一个随机元素。
            [0]: 这部分代码从上面提到的随机选取的元素中取出第一个元素。因为random.sample()返回的是一个列表，即使我们只选取了一个元素，它仍然是以列表的形式返回的
            """
            i = random.sample(user_ratings_train[u], 1)[0] #找到一个评分过的item
            # 随机选取一个用户u没有评分的项目
            j = random.randint(1, self.item_count)
            #一直在while循环直到j不在用户的交互矩阵中
            while j in user_ratings_train[u]:
                j = random.randint(1, self.item_count) #找到一个item，没有被评分
            #构成一个三元组（uesr,item_have_score,item_no_score)
            # python中的取值从0开始
            #u为用户编号
            u = u - 1
            #i为用户交互过的商品编号
            i = i - 1
            #j为用户没有交互过的商品编号
            j = j - 1
            #BPR
            """
            self.U：[user_len,20] self.V:[item_len,20]
            np.dot(self.U[u], self.V[i].T) = [1,20]*[20,1] = 一个标量
            r_ui 计算交互过的商品i评分
            """
            r_ui = np.dot(self.U[u], self.V[i].T) + self.biasV[i]
            #r_uj 计算没有交互过的商品j评分
            r_uj = np.dot(self.U[u], self.V[j].T) + self.biasV[j]
            #两者差异
            r_uij = r_ui - r_uj
            #损失函数 随机梯度更新所以只选取一对i和j
            loss_func = -1.0 / (1 + np.exp(r_uij))
            # 更新2个矩阵
            #self.V[i] - self.V[j]商品i矩阵减去商品j矩阵加上用户矩阵
            self.U[u] += -self.lr * (loss_func * (self.V[i] - self.V[j]) + self.reg * self.U[u])
            #分别更新两次商品矩阵
            self.V[i] += -self.lr * (loss_func * self.U[u] + self.reg * self.V[i])
            self.V[j] += -self.lr * (loss_func * (-self.U[u]) + self.reg * self.V[j])
            # 更新偏置项
            self.biasV[i] += -self.lr * (loss_func + self.reg * self.biasV[i])
            self.biasV[j] += -self.lr * (-loss_func + self.reg * self.biasV[j])
    '''
    函数说明：通过输入分解后的用户项目矩阵得到预测矩阵predict
    Parameters:
        输入分别后的用户项目矩阵
    Returns：
        输出相乘后的预测矩阵，即我们所要的评分矩阵
    '''
    def predict(self, user, item):
        #np.mat把数组转换为矩阵
        predict = np.mat(user) * np.mat(item.T)
        return predict

    #主函数
    def main(self):
        #获取U-I的{1:{2,5,1,2}....}数据
        user_ratings_train = self.load_data(self.train_data_path)
        #获取测试集的评分矩阵
        self.load_test_data(self.test_data_path)
        #将test_data矩阵拍平 把二维矩阵转为一维矩阵
        for u in range(self.user_count):
            for item in range(self.item_count):
                if int(self.test_data[u][item]) == 1:
                    self.test[u * self.item_count + item] = 1
                else:
                    self.test[u * self.item_count + item] = 0
        #训练
        for i in range(self.train_count):
            time_start = time();
            self.train(user_ratings_train)  #训练10000次完成
            print("第",i,"次训练耗时",time()-time_start)
        predict_matrix = self.predict(self.U, self.V) #将训练完成的矩阵內积
        # 预测 getA()用于把稀疏矩阵转为稠密表示
        self.predict_ = predict_matrix.getA().reshape(-1)  #.getA()将自身矩阵变量转化为ndarray类型的变量
        print("predict_new",self.predict_)
        #self.predict_为一个一维数据预测
        self.predict_ = pre_handel(user_ratings_train, self.predict_, self.item_count)
        #计算差距
        auc_score = roc_auc_score(self.test, self.predict_)
        print('AUC:', auc_score)
        # Top-K evaluation
        scores.topK_scores(self.test, self.predict_, 5, self.user_count, self.item_count)
    '''
    函数说明：对结果进行修正，即用户已经产生交互的用户项目进行剔除，只保留没有产生用户项目的交互的数据
    Paramaters:
        输入用户项目字典集，以及一维的预测矩阵，项目个数
    Returns:
        输出修正后的预测评分一维的预测矩阵
    '''
def pre_handel(set, predict, item_count):
    # Ensure the recommendation cannot be positive items in the training set.
    #set为字典{users:{items}}
    for u in set.keys():
        #查询用户u中所有交互过的商品
        for j in set[u]:
            #转成一维表示
            predict[(u - 1) * item_count + j - 1] = 0
    return predict

if __name__ == '__main__':
    #调用类的主函数
    bpr = BPR()
    bpr.main()
